Anglit distribution#

The anglit distribution is a bounded, symmetric continuous distribution with a cosine-shaped density on a finite interval.

A useful characterization is:

\[ X \sim \text{anglit} \quad\Longleftrightarrow\quad \sin(2X) \sim \mathrm{Uniform}(-1, 1). \]

Equivalently, if \(U \sim \mathrm{Uniform}(0,1)\) then

\[ X = \tfrac12\,\arcsin(2U-1) \]

has the standard anglit distribution.


Learning goals#

  • write down the PDF/CDF (including SciPy’s loc/scale form)

  • derive mean, variance, MGF/characteristic function, and entropy

  • implement inverse-CDF sampling with NumPy only

  • use scipy.stats.anglit for evaluation, sampling, and fitting

import numpy as np

import plotly.graph_objects as go
import os
import plotly.io as pio
from plotly.subplots import make_subplots

from scipy import stats
from scipy.stats import anglit as anglit_sp

pio.templates.default = "plotly_white"
pio.renderers.default = os.environ.get("PLOTLY_RENDERER", "notebook")

np.set_printoptions(precision=6, suppress=True)
rng = np.random.default_rng(7)

PI = np.pi
A = PI / 4  # standard support bound

1) Title & classification#

  • Name: anglit

  • Type: continuous distribution

  • Standard support: \(x \in \left[-\tfrac{\pi}{4},\,\tfrac{\pi}{4}\right]\)

  • Parameter space (SciPy location–scale form):

    • location: \(\mathrm{loc} \in \mathbb{R}\)

    • scale: \(\mathrm{scale} > 0\)

  • Support with loc/scale: $\(x \in \left[\mathrm{loc} - \tfrac{\pi}{4}\,\mathrm{scale},\ \mathrm{loc} + \tfrac{\pi}{4}\,\mathrm{scale}\right].\)$

2) Intuition & motivation#

What it models#

The standard anglit density is

\[ f(x) = \cos(2x)\,\mathbf{1}\{ |x| \le \pi/4 \}. \]

So it is:

  • symmetric around 0 (a natural model for centered angular error)

  • bounded (no probability outside \([-\pi/4,\pi/4]\))

  • peaked at 0 and smoothly goes to 0 at the boundaries

A “uniform-through-a-sine” view#

Because the CDF has a sine in it, a clean intuition is the transformation:

\[ Z = \sin(2X) \sim \mathrm{Uniform}(-1,1). \]

This makes anglit useful as a bounded alternative to Gaussian noise when you want:

  • symmetry around a location

  • finite support

  • a smooth density that vanishes at the edges

Typical use cases#

In practice, anglit is not as common as Normal/Uniform/Von Mises for angles, but it can be a handy choice when your domain knowledge says angles cannot exceed a hard limit (e.g., mechanical tolerances, limited field-of-view jitter) and you want a smooth, unimodal shape.

Relations to other distributions#

  • Location–scale family: if \(Y\) is standard anglit then \(X=\mathrm{loc}+\mathrm{scale}\,Y\) is the general form used in SciPy.

  • Inverse-CDF sampling is closed form because the CDF is a sine.

  • Bounded symmetric noise: compared to a truncated Normal, anglit has a particularly simple PDF/CDF and exact inverse CDF.

def anglit_pdf(x, loc=0.0, scale=1.0):
    """Anglit PDF in SciPy's loc/scale parameterization (NumPy-only)."""
    if not (scale > 0):
        raise ValueError("scale must be > 0")
    x = np.asarray(x, dtype=float)
    z = (x - loc) / scale
    out = np.zeros_like(z, dtype=float)
    mask = (z >= -A) & (z <= A)
    out[mask] = np.cos(2 * z[mask]) / scale
    return out


def anglit_cdf(x, loc=0.0, scale=1.0):
    """Anglit CDF in SciPy's loc/scale parameterization (NumPy-only)."""
    if not (scale > 0):
        raise ValueError("scale must be > 0")
    x = np.asarray(x, dtype=float)
    z = (x - loc) / scale
    out = np.zeros_like(z, dtype=float)

    out[z >= A] = 1.0
    inner = (z > -A) & (z < A)
    out[inner] = 0.5 * (np.sin(2 * z[inner]) + 1.0)
    return out


def anglit_ppf(u, loc=0.0, scale=1.0):
    """Inverse CDF (percent point function) for u in [0, 1] (NumPy-only)."""
    if not (scale > 0):
        raise ValueError("scale must be > 0")
    u = np.asarray(u, dtype=float)
    if np.any((u < 0) | (u > 1)):
        raise ValueError("u must be in [0, 1]")
    return loc + scale * 0.5 * np.arcsin(2 * u - 1)


x_grid = np.linspace(-A, A, 600)

fig = make_subplots(rows=1, cols=2, subplot_titles=["PDF (standard)", "CDF (standard)"])
fig.add_trace(go.Scatter(x=x_grid, y=anglit_pdf(x_grid), mode="lines", name="pdf"), row=1, col=1)
fig.add_trace(go.Scatter(x=x_grid, y=anglit_cdf(x_grid), mode="lines", name="cdf"), row=1, col=2)
fig.update_xaxes(title_text="x", row=1, col=1)
fig.update_xaxes(title_text="x", row=1, col=2)
fig.update_yaxes(title_text="density", row=1, col=1)
fig.update_yaxes(title_text="probability", row=1, col=2)
fig.update_layout(width=950, height=380, showlegend=False)
fig.show()

3) Formal definition#

Standard form#

Support: \(x \in [-\pi/4,\pi/4]\).

PDF $\( f(x) = \cos(2x)\,\mathbf{1}\{-\pi/4 \le x \le \pi/4\}. \)$

CDF $\( F(x)=\begin{cases} 0, & x < -\pi/4,\\ \tfrac12\bigl(\sin(2x)+1\bigr), & -\pi/4 \le x \le \pi/4,\\ 1, & x > \pi/4. \end{cases} \)$

Location–scale form (SciPy)#

If \(Y\) is standard anglit and \(X = \mathrm{loc}+\mathrm{scale}\,Y\) with \(\mathrm{scale}>0\), then

\[ f_X(x;\mathrm{loc},\mathrm{scale}) = \frac{1}{\mathrm{scale}}\,\cos\!\left(2\,\frac{x-\mathrm{loc}}{\mathrm{scale}}\right) \ \mathbf{1}\left\{\left|\frac{x-\mathrm{loc}}{\mathrm{scale}}\right| \le \frac{\pi}{4}\right\}, \]

and

\[ F_X(x;\mathrm{loc},\mathrm{scale}) = F_Y\!\left(\frac{x-\mathrm{loc}}{\mathrm{scale}}\right). \]

4) Moments & properties#

Because the support is finite and the PDF is smooth, all moments exist.

Mean and variance (standard)#

Symmetry gives \(\mathbb{E}[X]=0\).

A closed form for the variance is:

\[ \mathrm{Var}(X) = \frac{\pi^2}{16} - \frac12. \]

Skewness and kurtosis#

  • Skewness: \(0\) (symmetry)

  • Fourth moment: $\(\mathbb{E}[X^4] = \frac{\pi^4}{256} - \frac{3\pi^2}{16} + \frac{3}{2}\)$

  • Kurtosis: $\(\kappa = \frac{\mathbb{E}[X^4]}{\mathrm{Var}(X)^2},\qquad \kappa_{\mathrm{excess}}=\kappa-3.\)$

MGF and characteristic function#

For the standard distribution,

\[ M(t) = \mathbb{E}[e^{tX}] = \int_{-\pi/4}^{\pi/4} e^{tx}\cos(2x)\,dx = \frac{4\,\cosh\!\left(\tfrac{\pi t}{4}\right)}{t^2+4}. \]

The characteristic function is

\[ \varphi(t) = \mathbb{E}[e^{itX}] = \frac{4\,\cos\!\left(\tfrac{\pi t}{4}\right)}{4-t^2}, \]

with removable singularities at \(t=\pm 2\) (the limit exists).

Entropy#

The differential entropy of the standard distribution is

\[ h(X) = -\int f(x)\log f(x)\,dx = 1-\log 2\ \ \text{(nats)}. \]

For the location–scale form, \(h(\mathrm{loc}+\mathrm{scale}Y)=h(Y)+\log(\mathrm{scale})\).

# Closed-form moments/properties (standard)
mean_closed = 0.0
var_closed = PI**2 / 16 - 0.5
m4_closed = PI**4 / 256 - 3 * PI**2 / 16 + 1.5
kurt_closed = m4_closed / var_closed**2
excess_closed = kurt_closed - 3
entropy_closed = 1 - np.log(2)

mean_scipy, var_scipy, skew_scipy, kurt_excess_scipy = anglit_sp.stats(moments="mvsk")
entropy_scipy = anglit_sp.entropy()

print('mean (closed)  :', mean_closed)
print('mean (SciPy)   :', float(mean_scipy))
print('var (closed)   :', var_closed)
print('var (SciPy)    :', float(var_scipy))
print('skew (SciPy)   :', float(skew_scipy))
print('kurtosis excess (closed):', excess_closed)
print('kurtosis excess (SciPy) :', float(kurt_excess_scipy))
print('entropy (closed):', entropy_closed)
print('entropy (SciPy) :', float(entropy_scipy))
mean (closed)  : 0.0
mean (SciPy)   : 0.0
var (closed)   : 0.11685027506808487
var (SciPy)    : 0.11685027506808487
skew (SciPy)   : 0.0
kurtosis excess (closed): -0.8062497699541868
kurtosis excess (SciPy) : -0.8062497699541786
entropy (closed): 0.3068528194400547
entropy (SciPy) : 0.3068528194400547

5) Parameter interpretation#

SciPy exposes anglit as a location–scale family:

\[ X = \mathrm{loc} + \mathrm{scale}\,Y,\qquad Y\sim\text{standard anglit},\ \ \mathrm{scale}>0. \]
  • loc shifts the distribution left/right (mean/median/mode all move to loc).

  • scale stretches the support and rescales the density height by \(1/\mathrm{scale}\).

  • The shape as a function of the standardized variable \(z=(x-\mathrm{loc})/\mathrm{scale}\) stays the same: \(\cos(2z)\) on \([-\pi/4,\pi/4]\).

loc = 0.5
scales = [0.4, 0.8, 1.4]

x = np.linspace(loc - max(scales) * A, loc + max(scales) * A, 800)

fig = go.Figure()
for s in scales:
    fig.add_trace(go.Scatter(x=x, y=anglit_pdf(x, loc=loc, scale=s), mode="lines", name=f"scale={s}"))

fig.update_layout(
    title="Anglit PDF under different scales (loc fixed)",
    xaxis_title="x",
    yaxis_title="density",
    width=900,
    height=420,
)
fig.show()

6) Derivations#

Expectation#

For the standard distribution,

\[ \mathbb{E}[X] = \int_{-\pi/4}^{\pi/4} x\cos(2x)\,dx. \]

The integrand is an odd function (product of odd \(x\) and even \(\cos(2x)\)), so the integral over a symmetric interval is 0.

Variance#

Since \(\mathbb{E}[X]=0\),

\[ \mathrm{Var}(X) = \mathbb{E}[X^2] = \int_{-\pi/4}^{\pi/4} x^2\cos(2x)\,dx. \]

Use evenness to write it as \(2\int_0^{\pi/4} x^2\cos(2x)\,dx\) and integrate by parts:

\[ \int x^2\cos(2x)\,dx = \frac12 x^2\sin(2x) + \frac12 x\cos(2x) - \frac14\sin(2x) + C. \]

Evaluate from \(0\) to \(\pi/4\) (where \(\sin(\pi/2)=1\) and \(\cos(\pi/2)=0\)) to get

\[ \mathrm{Var}(X) = \frac{\pi^2}{16} - \frac12. \]

Likelihood (i.i.d. sample)#

For observations \(x_1,\dots,x_n\) and parameters \((\mathrm{loc},\mathrm{scale})\) with \(\mathrm{scale}>0\),

\[ \ell(\mathrm{loc},\mathrm{scale}) = \sum_{i=1}^n \log f_X(x_i;\mathrm{loc},\mathrm{scale}) = -n\log(\mathrm{scale}) + \sum_{i=1}^n \log\cos\!\left(2\,\frac{x_i-\mathrm{loc}}{\mathrm{scale}}\right), \]

with the support constraint \(\left|\tfrac{x_i-\mathrm{loc}}{\mathrm{scale}}\right|\le \pi/4\) for all \(i\) (otherwise the likelihood is 0).

This log-likelihood is smooth inside the feasible region but goes to \(-\infty\) when any sample approaches the boundary (because \(\cos(2z)\to 0\) as \(|z|\to\pi/4\)).

# Quick numerical sanity check of mean/variance via a fine grid
x = np.linspace(-A, A, 400_001)
pdf = anglit_pdf(x)
dx = x[1] - x[0]

mean_num = np.sum(x * pdf) * dx
var_num = np.sum((x - mean_num) ** 2 * pdf) * dx

print('mean (grid integral):', mean_num)
print('var  (grid integral):', var_num)
print('var  (closed)      :', var_closed)
mean (grid integral): -3.3929984748491564e-17
var  (grid integral): 0.1168502750644443
var  (closed)      : 0.11685027506808487

7) Sampling & simulation (NumPy only)#

Because the CDF is explicit,

\[ F(x) = \tfrac12\bigl(\sin(2x)+1\bigr),\qquad x\in[-\pi/4,\pi/4], \]

we can sample by inverse transform sampling:

  1. Draw \(U \sim \mathrm{Uniform}(0,1)\).

  2. Solve \(U = \tfrac12(\sin(2X)+1)\): $\(\sin(2X) = 2U-1\ \Rightarrow\ 2X = \arcsin(2U-1)\ \Rightarrow\ X = \tfrac12\arcsin(2U-1).\)$

  3. Apply loc/scale if desired: \(X_{\mathrm{ls}} = \mathrm{loc}+\mathrm{scale}\,X\).

This is numerically stable as long as we avoid passing values outside \([-1,1]\) into arcsin (so clamp or validate if you construct \(U\) in unusual ways).

def sample_anglit(size, loc=0.0, scale=1.0, rng=None):
    """Sample from anglit(loc, scale) using inverse CDF (NumPy-only)."""
    if rng is None:
        rng = np.random.default_rng()
    u = rng.uniform(0.0, 1.0, size=size)
    return anglit_ppf(u, loc=loc, scale=scale)


n = 50_000
samples = sample_anglit(n, rng=rng)

# Transformation check: sin(2X) should look Uniform(-1,1)
z = np.sin(2 * samples)

print('samples mean ~', samples.mean())
print('samples var  ~', samples.var())
print('closed-form var', var_closed)
print('z (sin(2X)) mean ~', z.mean())
print('z (sin(2X)) min/max:', z.min(), z.max())
samples mean ~ 0.0017822315715813274
samples var  ~ 0.11693584576419093
closed-form var 0.11685027506808487
z (sin(2X)) mean ~ 0.002866540728205996
z (sin(2X)) min/max: -0.9999568082303634 0.9998657111910878

8) Visualization#

Below are:

  • the analytic PDF and CDF

  • a Monte Carlo histogram overlaid with the PDF

x_grid = np.linspace(-A, A, 800)
pdf_grid = anglit_pdf(x_grid)
cdf_grid = anglit_cdf(x_grid)

fig = make_subplots(
    rows=1,
    cols=3,
    subplot_titles=["PDF", "CDF", "Samples (hist) + PDF"],
)

fig.add_trace(go.Scatter(x=x_grid, y=pdf_grid, mode="lines", name="pdf"), row=1, col=1)
fig.add_trace(go.Scatter(x=x_grid, y=cdf_grid, mode="lines", name="cdf"), row=1, col=2)

fig.add_trace(
    go.Histogram(
        x=samples,
        nbinsx=60,
        histnorm="probability density",
        name="samples",
        opacity=0.6,
    ),
    row=1,
    col=3,
)
fig.add_trace(go.Scatter(x=x_grid, y=pdf_grid, mode="lines", name="pdf"), row=1, col=3)

for c in [1, 2, 3]:
    fig.update_xaxes(title_text="x", row=1, col=c)

fig.update_yaxes(title_text="density", row=1, col=1)
fig.update_yaxes(title_text="probability", row=1, col=2)
fig.update_yaxes(title_text="density", row=1, col=3)

fig.update_layout(width=1100, height=380, showlegend=False)
fig.show()

9) SciPy integration (scipy.stats.anglit)#

scipy.stats.anglit is the standardized distribution plus loc and scale.

Common methods:

  • anglit_sp.pdf(x, loc, scale)

  • anglit_sp.cdf(x, loc, scale)

  • anglit_sp.rvs(loc=..., scale=..., size=..., random_state=...)

  • anglit_sp.fit(data) → estimates (loc, scale)

# Match our NumPy-only PDF/CDF to SciPy
x = np.linspace(-A, A, 10)
print('max |pdf - scipy|:', np.max(np.abs(anglit_pdf(x) - anglit_sp.pdf(x))))
print('max |cdf - scipy|:', np.max(np.abs(anglit_cdf(x) - anglit_sp.cdf(x))))

# Demonstrate rvs + fit on location-scale data
loc_true, scale_true = 1.2, 0.7
data = anglit_sp.rvs(loc=loc_true, scale=scale_true, size=2000, random_state=rng)

loc_hat, scale_hat = anglit_sp.fit(data)
print('true loc/scale:', (loc_true, scale_true))
print('fit  loc/scale:', (loc_hat, scale_hat))

# Visualize fitted vs true
x_grid = np.linspace(loc_true - scale_true * A, loc_true + scale_true * A, 600)

fig = go.Figure()
fig.add_trace(go.Histogram(x=data, nbinsx=60, histnorm='probability density', name='data', opacity=0.55))
fig.add_trace(go.Scatter(x=x_grid, y=anglit_sp.pdf(x_grid, loc=loc_true, scale=scale_true), mode='lines', name='true pdf'))
fig.add_trace(go.Scatter(x=x_grid, y=anglit_sp.pdf(x_grid, loc=loc_hat, scale=scale_hat), mode='lines', name='fit pdf'))
fig.update_layout(
    title='SciPy anglit: fit() on synthetic location–scale data',
    xaxis_title='x',
    yaxis_title='density',
    width=900,
    height=420,
)
fig.show()
max |pdf - scipy|: 0.0
max |cdf - scipy|: 1.1102230246251565e-16
true loc/scale: (1.2, 0.7)
fit  loc/scale: (1.2007715567009445, 0.7011704429753689)

10) Statistical use cases#

Hypothesis testing (goodness-of-fit)#

You might test whether a bounded, symmetric error distribution is better modeled as anglit than (say) Uniform or truncated Normal.

A common approach is a goodness-of-fit test like Kolmogorov–Smirnov (KS). Caution: if you estimate loc/scale from the data and then run a vanilla KS test, the p-value is not exact. A practical workaround is a parametric bootstrap that repeats the fitting step.

Bayesian modeling#

Anglit can be a reasonable likelihood/prior for an angular offset when:

  • the offset is centered around some location loc

  • deviations are bounded by \(\tfrac{\pi}{4}\,\mathrm{scale}\)

We’ll show a simple grid posterior for loc with known scale.

Generative modeling#

In simulation pipelines, anglit is a simple way to add bounded, smooth noise (e.g., small orientation jitter) with exact inverse-CDF sampling.

# Hypothesis testing: parametric bootstrap KS for fitted anglit

def ks_statistic_to_fitted_anglit(sample):
    loc_hat, scale_hat = anglit_sp.fit(sample)
    fitted = anglit_sp(loc=loc_hat, scale=scale_hat)
    return stats.kstest(sample, fitted.cdf).statistic


n = 400
loc_true, scale_true = 0.2, 0.9
x_obs = anglit_sp.rvs(loc=loc_true, scale=scale_true, size=n, random_state=rng)

D_obs = ks_statistic_to_fitted_anglit(x_obs)

B = 250  # keep modest for notebook runtime
loc_hat, scale_hat = anglit_sp.fit(x_obs)
fitted = anglit_sp(loc=loc_hat, scale=scale_hat)

Ds = np.empty(B)
for b in range(B):
    sim = fitted.rvs(size=n, random_state=rng)
    Ds[b] = ks_statistic_to_fitted_anglit(sim)

p_boot = (np.sum(Ds >= D_obs) + 1) / (B + 1)

print('KS statistic (observed):', D_obs)
print('bootstrap p-value      :', p_boot)
KS statistic (observed): 0.03853867853294057
bootstrap p-value      : 0.4262948207171315
# Bayesian modeling: grid posterior for loc with known scale (and uniform prior)

def anglit_logpdf(x, loc=0.0, scale=1.0):
    if not (scale > 0):
        raise ValueError('scale must be > 0')
    x = np.asarray(x, dtype=float)
    z = (x - loc) / scale
    out = np.full_like(z, -np.inf, dtype=float)
    mask = (z >= -A) & (z <= A)
    out[mask] = np.log(np.cos(2 * z[mask])) - np.log(scale)
    return out


scale_known = 0.8
loc_true = -0.3
x_obs = anglit_sp.rvs(loc=loc_true, scale=scale_known, size=120, random_state=rng)

# Uniform prior over a plausible interval
grid = np.linspace(-1.5, 1.5, 1000)
loglike = np.array([anglit_logpdf(x_obs, loc=mu, scale=scale_known).sum() for mu in grid])

logpost = loglike - loglike.max()  # stabilize
post = np.exp(logpost)
post /= np.trapz(post, grid)

mu_map = grid[np.argmax(post)]

fig = go.Figure()
fig.add_trace(go.Scatter(x=grid, y=post, mode='lines', name='posterior'))
fig.add_vline(x=loc_true, line_dash='dash', line_color='black', annotation_text='true loc')
fig.add_vline(x=mu_map, line_dash='dot', line_color='red', annotation_text='MAP')
fig.update_layout(
    title='Posterior over loc (uniform prior, scale known)',
    xaxis_title='loc',
    yaxis_title='density',
    width=900,
    height=380,
)
fig.show()
# Generative modeling example: bounded angular jitter

def wrap_to_pi(angle):
    """Wrap angle to (-pi, pi]."""
    return (angle + PI) % (2 * PI) - PI


m = 5000
theta = rng.uniform(-PI, PI, size=m)                   # latent direction
eps = sample_anglit(m, loc=0.0, scale=0.15, rng=rng)    # bounded jitter
y = wrap_to_pi(theta + eps)

fig = make_subplots(rows=1, cols=2, subplot_titles=["Jitter ε", "Wrapped observation y = wrap(θ+ε)"])

fig.add_trace(go.Histogram(x=eps, nbinsx=60, histnorm='probability density', name='eps'), row=1, col=1)
fig.add_trace(go.Histogram(x=y, nbinsx=80, histnorm='probability density', name='y'), row=1, col=2)

fig.update_xaxes(title_text='ε', row=1, col=1)
fig.update_xaxes(title_text='y', row=1, col=2)
fig.update_yaxes(title_text='density', row=1, col=1)
fig.update_yaxes(title_text='density', row=1, col=2)
fig.update_layout(width=1050, height=380, showlegend=False)
fig.show()

11) Pitfalls#

  • Scale must be positive: scale <= 0 is invalid.

  • Hard support constraint: values outside \([\mathrm{loc}-\tfrac{\pi}{4}\mathrm{scale},\ \mathrm{loc}+\tfrac{\pi}{4}\mathrm{scale}]\) have PDF 0 and log-PDF \(-\infty\).

  • Boundary behavior: the PDF goes to 0 at the endpoints, so logpdf goes to \(-\infty\); this can make optimization/fit sensitive if observations lie extremely close to the boundary.

  • Inverse CDF edge cases: for \(u\) extremely close to 0 or 1, floating point roundoff can push \(2u-1\) slightly outside \([-1,1]\); clip if needed.

  • Goodness-of-fit with fitted parameters: if you fit loc/scale and then run a KS test, use a bootstrap (or another method) if you need calibrated p-values.

12) Summary#

  • Anglit is a continuous, bounded, symmetric distribution with PDF \(\cos(2x)\) on \([-\pi/4,\pi/4]\).

  • Its CDF is explicit, enabling exact inverse-CDF sampling: \(X=\tfrac12\arcsin(2U-1)\).

  • Key closed forms (standard):

    • mean \(0\)

    • variance \(\pi^2/16 - 1/2\)

    • entropy \(1-\log 2\) (nats)

    • MGF \(M(t)=\dfrac{4\cosh(\pi t/4)}{t^2+4}\)

  • In SciPy, use loc/scale for shifting and scaling: scipy.stats.anglit.